Skip to content

Documents: Use single-transaction create/update-and-publish endpoints (FE)#23031

Open
iOvergaard wants to merge 10 commits into
v17/improvement/save-and-publish-take-threefrom
v17/feature/68071-save-and-publish-fe
Open

Documents: Use single-transaction create/update-and-publish endpoints (FE)#23031
iOvergaard wants to merge 10 commits into
v17/improvement/save-and-publish-take-threefrom
v17/feature/68071-save-and-publish-fe

Conversation

@iOvergaard
Copy link
Copy Markdown
Contributor

@iOvergaard iOvergaard commented Jun 1, 2026

Description

Frontend half of the single-transaction save-and-publish work (ADO 68071, under PBI 68081). The backend endpoints come from #22812, and this targets that feature branch so the whole thing lands on v17/dev together.

Save-and-publish used to be two server calls — PUT /document/{id} (save) then PUT /document/{id}/publish — plus a reload. This wires the backoffice to the new combined endpoints so it's a single transaction.

Where it lives

All the publish-related HTTP sits in the publishing domain rather than the detail layer (kept there on review feedback, so other entities/actions can reuse it if they grow combined endpoints of their own):

  • UmbDocumentPublishingServerDataSource / UmbDocumentPublishingRepository — new createAndPublish / updateAndPublish, plus a #mapCulturesToPublish helper that emits only real culture codes (invariant content types send an empty array, matching the server contract).
  • Shared request-body mappers (umbMapDocumentCreateRequestBody / umbMapDocumentUpdateRequestBody) build the bodies and are used by both the plain create/update and the and-publish calls, so there's no duplication.
  • UmbDocumentWorkspaceContext — public finalizeCreate / finalizeUpdate apply the same create/update workspace lifecycle the base save flow would (mark no-longer-new, tree events, reconcile the data state) after the publishing context has made the call. This keeps setIsNew and the workspace event discriminator private, so there are no shared base-class changes. Plus transferPublishedVariantsToCurrent (below).
  • UmbDocumentPublishingWorkspaceContext.#performSaveAndPublish orchestrates the flow: one combined call → reload → transfer.

Keeping unpublished edits dirty (ADO 68071)

After save-and-publish, the reload resets both persisted and current state to the full server document. transferPublishedVariantsToCurrent re-applies the still-unpublished, edited variants from the pre-publish draft onto the current state (via UmbMergeContentVariantDataController), so a variant you changed but did not publish stays dirty and the Discard-Changes guard still fires. Verified: edit English + Danish, publish English only → Danish stays dirty with its edit intact.

No spurious "Discard unsaved changes" dialog on create

Creating and publishing a brand-new document flips isNew, which triggers the new→edit redirect, and that redirect's navigation guard was catching the workspace mid-transition and popping a Discard dialog. finalizeCreate now reconciles both persisted and current to the saved data before the flip, so the guard sees a clean workspace whatever the doc type — the dirty check is an order-sensitive JSON comparison, and the saved data can order its values differently from the draft, so reconciling only one side wasn't enough. The unpublished variants are restored (dirty again) by the reload + transfer that follow, so nothing is lost. Thanks to @AndyButland for spotting this and pinning down the ordering cause.

Not changed

  • Scheduling still uses the separate publish endpoint (the combined endpoints are immediate-publish only).
  • The validation-failure path still falls back to a plain save (no publish).
  • No deprecations — the existing publish() methods are still used by scheduling, the publish entity action, and the publish bulk action.

Testing

  • Unit tests (89 document tests passing): the shared mappers; the publishing data source maps culturesToPublish correctly; the 68071 data-state behaviour (published variant clean, unpublished edit stays dirty — fails on the pre-fix code, passes after); a single combined call (no separate publish); and the create-and-publish path staying clean through the redirect window then restoring the unpublished edit after reload + transfer.
  • OpenAPI client regenerated from the merged OpenApi.json.
  • Verified in a production build against a real backend: create-and-publish (an invariant doc with an empty RTE — the case Andy reproduced), update-and-publish, and the variant 68071 flow (publish English only; Danish stays dirty and keeps its edit).

Loading / fetch behaviour

Progress during save & publish shows only on the action button (the in-progress setExecuting state from #22551). The full-workspace loader is intentionally not raised — what's on screen is what was just published, so there's no reason to blank and reload the editor. reload() refreshes the workspace state in place without toggling the loading flag.

Post-publish requests are one GET /document/{id} (reload), GET /document/{id}/published (recomputes the published-vs-draft "pending changes" indicator — pre-existing behaviour, its result is used), and a background GET /tree/document/{id}/siblings. The redundant second GET /document/{id} the data source used to issue after mutating has been removed, so the document is fetched once post-publish.

Related to #21152

🤖 Generated with Claude Code

iOvergaard and others added 2 commits June 1, 2026 13:34
… (FE)

Replace the separate save+publish calls on the document save-and-publish path
with the new create-and-publish / update-and-publish endpoints, and keep
unpublished but edited variants dirty after reload via a current-state re-merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-take-three' into v17/feature/68071-save-and-publish-fe
Copilot AI review requested due to automatic review settings June 1, 2026 11:53
@claude
Copy link
Copy Markdown

claude Bot commented Jun 1, 2026

Claude finished @iOvergaard's task in 5m 24s —— View job


PR Review

Target: origin/v17/improvement/save-and-publish-take-three · Based on commit: c25740bc · Skipped: 2 files out of 10 total (sdk.gen.ts, types.gen.ts — auto-generated)

Wires the document backoffice to new combined create/update-and-publish endpoints, replacing the previous two-step save-then-publish flow with a single atomic server call; adds logic to re-apply unpublished variant edits after the reload so they remain dirty.

  • Modified public API: UmbDocumentServerDataSource (+createAndPublish, +updateAndPublish), UmbDocumentDetailRepository (+createAndPublish, +updateAndPublish), UmbDocumentWorkspaceContext (+performCreateOrUpdateAndPublish, +transferPublishedVariantsToCurrent)
  • Other changes: Save-and-publish is now a single atomic server transaction (previously: save → publish → reload). An edited variant that wasn't included in the publish selection now survives the reload as a dirty state.

Suggestions

  • document-detail.repository.ts:23: parentUnique has no default value in the repository method, while the underlying data source declares parentUnique: string | null = null. Callers of the repository's createAndPublish must always pass the argument explicitly, which is inconsistent with the data source signature and the analogous base-class create method. Adding = null here makes the API surface consistent. Fix this →

  • document-workspace.context.ts:408: performCreateOrUpdateAndPublish is public but intentionally does not update persisted/current data — it requires a caller-side reload() + transferPublishedVariantsToCurrent() to leave the workspace in a valid state. The JSDoc communicates this, but a public method with an invisible post-condition is a footgun for any consumer that calls it standalone. Consider whether protected would be more appropriate (only the single in-package caller exists today), or at minimum add a @throws note spelling out the broken state that results if the follow-up is omitted.

  • document-workspace-save-and-publish.context.test.ts:109: The single-call verification test mutates UmbDocumentServerDataSource.prototype at runtime to count invocations. The try/finally cleanup is correct, but the test is white-box: it couples to the internal call path rather than observable server behaviour. A more resilient approach would intercept the MSW request log to assert that exactly one PUT /.../update-and-publish was received and zero PUT /document/:id (plain update) calls were made — that way the assertion survives if the implementation layer is refactored.


Approved with Suggestions for improvement

Good to go, but please carefully consider the importance of the suggestions.

Labels applied: area/frontend, category/ux

@claude claude Bot added area/frontend category/ux User experience labels Jun 1, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Documents backoffice save-and-publish flow to use the new single-transaction Management API endpoints (create-and-publish / update-and-publish), and adds client-side state handling to keep unpublished edits “dirty” after a publish-triggered reload.

Changes:

  • Add createAndPublish / updateAndPublish to the document detail server data source (plus culturesToPublish mapping + re-read).
  • Wire the publishing workspace to call the combined operation (instead of save + publish) and restore unpublished edits after reload.
  • Add unit tests covering the save-and-publish data-state behavior and the single combined call path, plus MSW/mock support for the new endpoints.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace.context.ts Adds combined create/update-and-publish orchestration and post-reload “dirty variant” restoration logic.
src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-save-and-publish.context.test.ts New unit tests verifying dirty-state behavior after save-and-publish and ensuring only one combined call is used.
src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts Adds createAndPublish / updateAndPublish methods and culturesToPublish mapping with re-read.
src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.test.ts New unit tests for update-and-publish behavior (requested cultures + invariant case).
src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.repository.ts Exposes repository pass-throughs for the new server data source methods.
src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts Switches save-and-publish flow to the combined endpoint and reapplies unpublished edits after reload.
src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts Regenerates OpenAPI types to include new request models and routes.
src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts Regenerates OpenAPI SDK methods for the new endpoints.
src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/detail.handlers.ts Adds MSW handlers for create-and-publish and update-and-publish endpoints.
src/Umbraco.Web.UI.Client/mocks/db/document-publishing.manager.ts Adds mock DB publishing support for combined create/update-and-publish operations.

- Deduplicate request-body mapping in the document data source (shared
  #mapCreateRequestBody / #mapUpdateRequestBody) to remove create vs
  create-and-publish duplication (CodeScene).
- Split performCreateOrUpdateAndPublish into #createAndPublish / #updateAndPublish
  to reduce method complexity (CodeScene), and clarify its post-condition in JSDoc.
- Default parentUnique to null on the repository createAndPublish overload (Claude).
- De-duplicate segments before expanding variant lists (Copilot).
- Add a createAndPublish data-source unit test (Copilot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create/update-and-publish data-source methods re-read the full document
after the mutation, but the publishing workspace context immediately calls
reload() (which re-fetches and refreshes state) and discards that result.
Remove the data-source re-read so save-and-publish fetches the document once
(the reload) instead of twice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@madsrasmussen
Copy link
Copy Markdown
Member

Hi,

Is it possible to maintain the current domain split, with all publishing-related methods located in the publishing repository and publishing context? This would mean that methods like createAndPublish would be moved from the DocumentDetailRepository to the UmbDocumentPublishingRepository. I believe this separation of responsibilities aligns better with how package authors may want to extend a workspace with additional functionality, giving us as much flexibility as possible for workspace extensions. Additionally, it will be beneficial if other entities require publishing functionality, as we would only need to "borrow" the Publishing domain across.

@AndyButland
Copy link
Copy Markdown
Contributor

Reviewing purely from a testing perspective, I can see the endpoints being called as expected for "create and publish" and "update and publish".

There's an issue with "create and publish" in that after the operation completes there's an immediate and unnecessary display of the "Discard unsaved changes" dialog. If I click "Stay" and then navigate away, there's no secondary display of this dialog, which is correct, so seems there's an issue where this is incorrectly triggered immediately after the create and publish, presumably as it navigates to the "edit" view for the newly created document.

@iOvergaard
Copy link
Copy Markdown
Contributor Author

@madsrasmussen Great question!

Agreed on keeping the domain split. One heads-up: moving the createAndPublish/updateAndPublish HTTP methods to UmbDocumentPublishingRepository is easy (the publishing context already has the repo + saveData, and I'll share the request-body mappers).

The catch is the create/update lifecycle around it: setIsNew and the _workspaceEventUnique-stamped UmbEntityUpdatedEvent are protected on the shared base classes, so calling them from the publishing context means exposing them publicly (touches every entity workspace). Are you OK with that?

@iOvergaard
Copy link
Copy Markdown
Contributor Author

@AndyButland Interesting finding. I didn't see that myself before, but I will test it out. I think it may be caused by the fact that we now don't overwrite the whole dataset after create/update, because you may not have save-published all variants. Did you have any variants on your page, or any of the known problematic property editors, such as Tiptap with blocks on the document?

@AndyButland
Copy link
Copy Markdown
Contributor

AndyButland commented Jun 3, 2026

I checked - I see this on invariant documents, but it does seem to be property editor related, I've tested with an invariant document that does have an RTE on it, but I haven't populated it with anything.

@madsrasmussen
Copy link
Copy Markdown
Member

@iOvergaard I don’t fully remember the entire ceremony around create/update, but it is probably quite complicated. I think there is something interesting in exposing “just enough” so that additional workspace contexts will be able to “prepare” data for a create/save. So maybe we need to introduce new public methods to support this 🤔 I could imagine that other actions might be interested in doing a save/create before their own action and might introduce combined endpoints too.

iOvergaard and others added 3 commits June 3, 2026 11:58
After create-and-publish the workspace flipped isNew=false before its data
state was reconciled (that happens in the subsequent reload + transfer). The
flip schedules the new->edit redirect, whose navigation guard then saw a
transient dirty state and popped a "Discard unsaved changes" dialog — most
visible on an invariant document with an empty RTE (reported by Andy Butland).

Reconcile persisted to the just-saved data before flipping isNew. Using
saveData (not the full current data) keeps published variants clean while
edited-but-unpublished variants stay dirty, so their edits remain preserved
and navigation-guarded during the brief pre-reload window. Update-and-publish
is unaffected (no redirect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Following review feedback (Mads Rasmussen), keep all publishing-related HTTP in
the publishing repository/data source rather than the document detail layer:

- Move createAndPublish/updateAndPublish from UmbDocumentServerDataSource +
  UmbDocumentDetailRepository to UmbDocumentPublishingServerDataSource +
  UmbDocumentPublishingRepository. Extract the shared create/update request-body
  mapping to document-detail-request.mappers.ts so it is not duplicated.
- The publishing workspace context orchestrates the combined call and asks the
  document workspace context to apply the create/update lifecycle via new public
  finalizeCreate/finalizeUpdate methods. This keeps setIsNew and
  _workspaceEventUnique private — no shared workspace base-class changes.
- finalizeCreate carries the create-and-publish fix (reconcile persisted to the
  saved data before the new->edit redirect) so an edited-but-unpublished variant
  stays dirty while published variants are clean.

Move the and-publish data-source test to the publishing folder; rework the
save-and-publish/create-and-publish context tests to drive the publishing data
source + finalize methods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#createOrUpdateAndPublish re-checked #documentWorkspaceContext although its only
caller (#performSaveAndPublish) already guards it; pass the narrowed reference in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AndyButland
Copy link
Copy Markdown
Contributor

I was still seeing the same issue @iOvergaard after pulling down the latest - but for one particular document type and not others if I tried to recreate. I found evidence of the problem though:

image

You see the elements in the values array are in a different order.

I'll give you the diff that looks to fix this in UmbDocumentWorkspaceContext (as would prefer you to review and apply if you agree it makes sense).

-	 * Persisted is reconciled to `savedData` (not the full current data) BEFORE flipping isNew: that flip
-	 * triggers the new->edit redirect, whose navigation guard would otherwise see a transient dirty state
-	 * (the caller's reload reconciles only afterwards) and pop a spurious "Discard unsaved changes" dialog
-	 * (reported by Andy Butland). Using `savedData` keeps published variants clean while edited-but-
-	 * unpublished variants correctly stay dirty, so their edits remain guarded during the pre-reload window.
+	 * Both persisted and current are reconciled to `savedData` BEFORE flipping isNew, so the new->edit
+	 * redirect that flip triggers can never see a dirty state and pop a spurious "Discard unsaved changes"
+	 * dialog. Edited-but-unpublished variants are temporarily absent from the current state until the
+	 * caller's reload + transferPublishedVariantsToCurrent restores them (dirty again).
 	 * @param {UmbDocumentDetailModel} savedData - The data that was sent to the server (constructSaveData)
 	 * @returns {Promise<void>}
 	 * @memberof UmbDocumentWorkspaceContext
 	 */
 	public async finalizeCreate(savedData: UmbDocumentDetailModel): Promise<void> {
 		const parent = this._internal_getCreateUnderParent();
 		if (!parent) throw new Error('Parent is not set');
 
+		// Set persisted AND current to the same data BEFORE flipping isNew: that flip triggers the
+		// new->edit redirect, whose navigation guard compares the two states with an order-sensitive
+		// JSON.stringify. `savedData` is a merge-processed projection of the draft (its values array can
+		// be ordered differently, and resolver-backed editors may rewrite values), so reconciling only
+		// persisted still leaves a spurious mismatch -> "Discard unsaved changes" dialog. 
+		// Edited-but-unpublished variants are restored (and become dirty again) by the
+		// caller's reload + transferPublishedVariantsToCurrent.
 		this._data.setPersisted(savedData);
+		this._data.setCurrent(savedData);
 		this.setIsNew(false);

The test document-workspace-create-and-publish.context.test.ts → "keeps an edited-but-unpublished variant dirty after create-and-publish (no data loss)" will need a look at after this, as it fails after this update and so is perhaps asserting the wrong thing.

  • ✅ is not dirty immediately after create-and-publish, before the reload reconciles state — passes (the dialog-window
    guarantee, now holds for all doc types)
  • ❌ keeps an edited-but-unpublished variant dirty after create-and-publish (no data loss) — fails at da stays dirty
    (edited but not published): expected false to be true (line 98)

Andy Butland found the create-and-publish "Discard unsaved changes" dialog
still appeared for some document types. The dirty guard's jsonStringComparison
is order-sensitive, and savedData (a merge-processed projection of the draft)
can order its values array differently from current — so reconciling only
persisted still left a spurious mismatch. Set both persisted and current to
savedData before flipping isNew, so the new->edit redirect can never observe a
dirty state. Edited-but-unpublished variants are restored (dirty again) by the
caller's reload + transferPublishedVariantsToCurrent.

Update the create-and-publish "no data loss" test to assert the end state
(after reload + transfer) instead of the intermediate redirect window, since
the unpublished variant's edit is briefly absent during it by design.

Diagnosis and fix proposed by Andy Butland.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@iOvergaard
Copy link
Copy Markdown
Contributor Author

Thanks Andy, that's a good find — the values ordering one is easy to miss. Pushed your fix in 9cbd92b0de4.

Confirmed it's what you said: the dirty check is an order-sensitive JSON.stringify compare, and savedData comes back from the merge controller with its values in a different order than the draft (and resolver editors can rewrite values too), so only reconciling persisted still read as dirty on some doc types.

finalizeCreate now sets both persisted and current to savedData before the flip to edit mode, so the redirect can't see unsaved changes on any doc type. The unpublished variants drop out of current briefly, but the reload + transfer straight after restore them (dirty again) — so no data loss.

You were right about the test as well — it was asserting the intermediate state. Moved it to check the end state after reload + transfer, and renamed it. Green here.

Give it another go on the doc type that was reproducing for you when you get a chance? Want to confirm it's gone on your side too.

@AndyButland
Copy link
Copy Markdown
Contributor

Yes, this is working as expected now @iOvergaard. I'll mark as approved from a testing perspective - but if you are still in discussions with @madsrasmussen and @nielslyngsoe on the code/architecture side of things, obviously please wait for merging until you have resolved everything there.

@iOvergaard
Copy link
Copy Markdown
Contributor Author

Thanks, @AndyButland.

@madsrasmussen, I implemented your suggestions. Would you check it, please, and let me know what you think?

* @returns {Promise<void>}
* @memberof UmbDocumentWorkspaceContext
*/
public async finalizeUpdate(): Promise<void> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems inconsistent that finalizeUpdate takes no arguments.

But finalizeCreate retrieves the updated data and sets that.

That is a bit of an annoying inconsistency, as they smell like two identical methods for the various needs, create/update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — finalizeUpdate now takes the saved data and reconciles persisted/current just like finalizeCreate (both mirroring their base-class counterparts), fixed in ad059a0.

…d docs

Review feedback from Niels: finalizeUpdate now takes the saved data and
reconciles persisted/current like finalizeCreate (mirroring the base
_update), the finalize*/transfer JSDocs describe only the method's own
responsibility, and the transfer parameter is renamed to currentData to
match the method name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines +417 to +428
// Clear stale published data and pending changes state so the
// persistedData observer does not run a comparison against outdated
// data during reload, which would briefly show a false-positive
// "pending changes" state.
this.#clear();

// reload the document so all states are updated after the publish operation
await this.#documentWorkspaceContext.reload();
await this.#loadAndProcessLastPublished();
// reload the document so all states are updated after the publish operation
await this.#documentWorkspaceContext.reload();

// The reload resets the current data state to the full server document. Re-apply the edits of the
// variants that were not published, so they remain dirty (and the Discard-Changes guard fires).
await this.#documentWorkspaceContext.transferPublishedVariantsToCurrent(dirtyData, variantIds);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is something about this part that we need to look into.
I did a little bit of investigation on it, and from how things works with Discard Changes, then this seems like the only option. But to me there is a lot of arrows pointing towards how the Dicard Changes mechanisme works.
The reason why it itches a bit, from an architectural standpoint, is that for un-saved variants we shortly change it to be like it is on the server and then revert back to the client-draft. To avoid the Discard Changes, I would rather like the discard changes to understand that switching between Create/Edit should not trigger the check for unsaved changes logic.
I would like to dive further into this at one point, but this is currently my only comment on this PR, so putting it here to leave that thought available for who comes around.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right — and that reconcile-then-revert is the bit that bugs me too, not just you. Presenting an unsaved variant as the server state for a split second and then flipping it back is the kind of thing that works but never sits quite right. Agree the real fix is teaching the guard that Create→Edit isn't a navigate-away, rather than dancing around it out here. Didn't want to drag it into this PR though since it's shared infra (media and the rest go through the same redirect), so I've split it out: #23086. Thanks for digging into it 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants